fix(sandbox): 白名单纳入本 bot 角色库子树,修复沙盒下角色系统整体不可用 - #644
Conversation
开了 sandbox 的 bot 只有 workingDir(= 当前角色目录)在 readWrite 白名单里, 角色库的其余部分不在白名单 → deny-by-default 下「有哪些角色 / 切换角色 / 新建角色」全部 Operation not permitted: - 「有哪些角色 / 切换角色」要枚举兄弟角色目录、读各自 .botmux-dir.json - 「新建角色」要写 users/<openId>/<slug>/,并复制库根的 _role-protocol.md - 切换之后「沉淀知识」写的是新角色目录下的 knowledge/ 与 .botmux-dir.json buildFsPolicy 因此多一个 roleLibrarySubtree(`<角色库根>/<appId>`),按 readWrite 注入。给 readWrite 而不是 readOnly 是因为最后一条:只读的话枚举和 切换看起来都正常,直到写知识那步才 EPERM,最难查。 按 appId 限定,不是整个角色库根 —— 兄弟 bot 的角色目录(及其中别的用户的私有 角色)仍由构造保证不可见,跨 bot 读隔离不变。这刻意比 validateRoleLibraryPath 更窄:后者只要求目标在角色库根之下,于是跨 bot 切换即使过了 daemon 侧校验,也 会在 fs 层被挡住。 三道收口(后两道是 codex 两轮定向 review 抓出来的,都能把这条便利规则变成越权): 1. appId 形状:它要被拼进白名单路径,`join(root, '../../.ssh')` 会归一成 `~/.ssh` 把 rw 授到库外。`..` 被 join 吃掉后 normalizeFsPath 的 `..` 拦截 够不到、realpath 也会抹平,所以必须在拼之前挡:限定单段目录名。 2. 末两段不许跟链:只 realpath 角色库根的**父目录**(`$HOME` 本身是符号链接的 机器不归一会静默 fail-open,所以上层必须归一、也允许是链接),然后 `botmux-roles` 与 `<appId>` 各自 lstat、必须是真目录。任一段是链接就不产生 规则 —— 否则它被预先摆成指向 `~/.ssh`、`~/.botmux` 或**另一个 bot 的角色库** 时,跟随解析会把链接目标当成本 bot 子树直接授 rw:任意目录读写 + 跨 bot 越权。 返回值天然 canonical,调用方不得再 realpath(再跟一次就把校验作废)。 3. 任何 deny 覆盖即不产生规则:source rank 只裁**同路径**冲突,机主写 `deny: ["~/botmux-roles"]` 时更深的 internal rw 会按最长前缀胜重新开放 `<appId>`。现在 baseline / user / mandatory 三类 deny 只要覆盖该子树,这条 规则整条不产生。 没用过角色的 bot 不产生这条规则;spawn 后才出现的子树要等下一次 spawn 生效 (bwrap 本来也无法 bind 不存在的源)。 明确不做、且不该只为这条规则加固的两件事(codex 复验提出,均需宿主级写权限—— 而拿到宿主级写权限的人本来就能直接改 bots.json 关掉沙盒;且策略里**每条**路径 规则都在同一时点做一次性检查,同样成立): - TOCTOU:校验后、真正 spawn/bind 前把目录换成符号链接。路径型沙盒(Seatbelt 吃路径字符串、bwrap 吃 bind 源)无法靠持 fd 关闭该窗口。 - 挂载点:末段是 bind/FUSE 挂载点时仍是「真目录」,能把挂载目标整棵授出去。 既有遗留(本 PR 未引入、也未加剧):大小写不敏感卷上两个仅大小写不同的 appId 指向同一角色库目录 —— 角色库按 appId 分目录这个布局本身的性质(不开沙盒也共享), 要治得在 bot 配置加载期按文件系统身份拒绝碰撞。
首审 by Claude(Botmux开发者(Claude))结论:安全实现扎实、测试有牙、影响面干净;唯一阻塞是一处 scoping/文档矛盾(P1),按现有 runbook 部署时本修复是 silent no-op。 验证过(均通过)
🟠 P1(阻塞):
|
|
To use Codex here, create a Codex account and connect to github. |
复审收敛(Claude + codex)— P1 修法更新codex 独立复现了 P1(human-slug 布局下 撤回:我原来建议「直接从 active
任何修法的三条硬约束:① 本 bot 自己的角色根(隔离)② 切换不可变(冻结)③ 匹配运营实际命名(否则回到 silent no-op)。
接线缺口(供作者注意):worker 的 建议落点:daemon 侧从本 bot 的 codex 仍在收口安全面( |
双审收敛结论:Request Changes(唯一阻塞 = P1)— by Claude + codex两轮独立复审(Claude 首审 + codex 二审)结论一致:单一阻塞项为 P1;安全收口本身未发现新越界。未经 @deepcoldy 确认不合码。 P1(阻塞,双方独立复现)
修法(双方一致,已从「按 active workingDir 推」修正为「冻结受信根」)不能按可变 active 安全面(codex 逐项,未发现新 blocker)
需 @deepcoldy 知情接受的既有面(作者已在 PR 描述披露,双方不另立 blocker)grant 是整棵 same-bot 子树 rw,含 独立验证(双方)head |
第三条独立对抗审计补充(Claude 侧后台 agent,真 build + 编译后 policy 跑对抗输入)与前两审收敛一致:无 P1、无沙盒逃逸、无跨 bot 泄漏、无 deny 打穿。三道核心防御全部成立( 两处非阻塞完整性缺口(与 codex 二审一致,均 P2/P3、blast radius 限本 bot own
三审收敛:唯一代码 blocker 仍是 P1(human-slug scoping)。维持 Request changes,等 @deepcoldy 确认。 |
作者回应:P1 认领,但提议反向修 —— 把每-bot 目录段的契约定为
|
| P1 | 跨 bot 洞 | 存量迁移 | 改动面 | |
|---|---|---|---|---|
目录段 = appId(本提议) |
文档 3 处 + 告警 | 同一处收口(1 行) | 需要 | docs + 1 处校验 + 1 行日志 |
| 冻结根(reviewer 建议) | 代码 | 仍需另修 | 不需要 | core/types.ts init IPC + daemon 侧解析 + 透传 + 集成测试 |
两条硬约束(本 bot 自己的根 / 切换不可变)appId 方案天然满足 —— 它来自 per-bot 受信配置,且不随 active cwd 变化。
请拍
- A:采纳 appId 契约。我改
deploy-runbook.md、role-protocol-template.md、role-system-design.md三处 + 加告警 + 把validateRoleLibraryPath收窄到<root>/<appId>(存量兼容:该目录不存在时回落到全局根校验并打 deprecation 日志,避免直接切不动)+ 写迁移步骤。 - B:坚持人类 slug。我按二审的「冻结受信根」铺 IPC 管线。
两条路我都会一并收掉三审提的两个非阻塞项:P2 —— roleLibDenied() 只枚举 access==='deny',机主在祖先设的 readOnly 会被更深的 internal rw 静默升级成 rw(我倾向不是抑制而是降级为 readOnly:机主说只读就给只读,角色枚举/切换仍可用,只是写不了 knowledge);P3 —— mandatoryDenyRegexes 纳入抑制判断(compileToBwrap 不消费 denyRegexes,Linux 侧确实无兜底,虽然当前不可利用)。
在拍板之前我不动代码,head 停在 1c76f65e。
问题
开了
sandbox: true的 bot,角色系统整体不可用——不是「不能新建角色」这种局部退化,是连「有哪些角色 / 切换角色」都直接Operation not permitted。沙盒是 deny-by-default 三档白名单,
buildFsPolicy()拿到的 botmux 内部路径只有workingDir/botHome/sessionDataDir(src/worker.ts的buildFsPolicy({…})),完全不认识角色库——~/botmux-roles此前只出现在src/core/role-library.ts和botmux role switch的目标校验里。而workingDir只等于当前角色目录,于是_role-protocol.md规定的每一步都落在白名单外:shared/*、users/<openId>/*,读各自.botmux-dir.jsonusers/<openId>/<slug>/,复制库根_role-protocol.mdknowledge/、回填.botmux-dir.json的url现状下机主只能自己往
bots.json的sandboxPaths手配一遍才能用角色系统——而这件事没有任何提示,撞上去只看到 EPERM。等于「开沙盒 = 静默禁用角色系统」。改动
FsPolicyContext加roleLibrarySubtree,按readWrite注入;worker 传本 bot 自己的<角色库根>/<appId>。为什么是 readWrite 而不是 readOnly:上表最后一行。只读的话枚举和切换看起来都正常,直到切换后写知识那步才 EPERM——最难查的那类失败。
为什么按 appId 限定、不是整个角色库根:兄弟 bot 的角色目录(及其中别的用户的私有角色)仍由构造保证不可见,跨 bot 读隔离不变。这刻意比
validateRoleLibraryPath更窄:后者只要求目标在角色库根之下,于是跨 bot 切换即使过了 daemon 侧校验,也会在 fs 层被挡住。三道收口(后两道是 codex 两轮定向 review 抓出来的)
1. appId 形状(
roleLibrarySubtree(),src/core/role-library.ts):appId 来自bots.json(机主自己写),但它要拼进白名单路径,join(root, '../../.ssh')会被归一成~/.ssh,把 rw 授到库外。注意..被join吃掉后normalizeFsPath的..拦截够不到,realpath也会抹平——所以必须在拼路径之前挡。限定单段目录名,不合法 → 不产生规则。2. 末两段不许跟链:只
realpath角色库根的父目录($HOME本身是符号链接的机器/home/u→/data00/home/u这一类不归一会静默 fail-open,所以上层必须归一、也允许是链接),然后botmux-roles与<appId>各自lstat、必须是真目录。任一段是符号链接就不产生规则——否则它被预先摆成指向~/.ssh、~/.botmux或**另一个 bot 的整棵角色库(含users/<别人 openId>/私有角色)**时,跟随解析会把链接目标当成本 bot 的子树直接授 rw:一条便利规则变成任意目录读写 + 跨 bot 越权。返回值天然 canonical,调用方不得再realpath/keepExisting(再跟一次就把这道校验作废,注释里写明了)。3. 任何 deny 覆盖即整条不产生:source rank 只裁同路径冲突,不同路径永远更深者胜。机主写
deny: ["~/botmux-roles"]想整个关掉角色库时,这条更深的 internal rw 会在被 deny 的库根上重新开个洞。现在 baseline / user / mandatory 三类 deny 只要覆盖该子树,规则整条不产生。存在性/canonical 化都在
roleLibrarySubtree()里做完,worker 不再走keepExisting(它的realpath正是第 2 条要防的东西)。测试
test/fs-policy.test.ts+3 例、test/role-library.test.ts+7 例:本 bot 子树 rw / 兄弟 bot 与库根不覆盖 / 同路径 deny / 祖先 deny(user、mandatory、baseline 三类)不得被打洞 / appId 形状(../../.ssh、..、.、a/b、/abs、空值、控制字符、空格 全部 → null)/ 末段是符号链接 → null(分别指向库外目录与另一个 bot 的角色库) / 库根本身是符号链接 → null / 根之上中间段是符号链接 → 放行且返回 canonical / 不存在或是文件 → null / 库根不存在 → null。变异测试(证明新断言不是恒真——codex 明确要求验这个):把 root 的
lstat校验去掉、把 baseline deny 从抑制列表里删掉,各自只杀掉对应的那条用例:内核级实测(macOS Seatbelt,
sandbox-exec -f <profile>真跑/bin/ls、/bin/cat、/usr/bin/touch,用的是本机真实~/botmux-roles树;committed e2e 做不到这点——它的 scratch 树在 TMPDIR 根下,而 darwin baseline 把 TMPDIR 设成 rw,那里的路径不论有没有这条规则都可写):npx tsc --noEmit通过;全量 vitest 与upstream/master同 commit 基线对照,失败文件名集合无新增(见下)。全量对照(同机、同 commit 基线 worktree,各 ~910s;判据是失败文件名集合,不是数字):
upstream/master7282bb68基线失败集合差异只有一个文件:
test/skill-agentbuddy-install.test.ts→agentbuddy_clear_telemetry_failed: spawnSync node ETIMEDOUT(满载下 14s 超时)。单跑 15/15 通过,3.36s —— 满载 spawnSync 超时的 flake,与本改动无关(本 PR 只碰 fs-policy / role-library / worker 的沙盒段,不碰 skill registry)。通过用例 +4 = 新增 5 例减去这 1 个 flake。影响面
sandbox: true的会话生效;未开沙盒的 bot 行为不变(那条路径本来就可访问)。~/.botmux里的凭证与兄弟数据、bots.json等一概不变,仍在白名单外。bots.json关掉沙盒;且策略里每条路径规则(workingDir、botHome、cliDataPaths…)都在同一时点做一次性存在性检查,同样成立。路径型沙盒(Seatbelt 吃路径字符串、bwrap 吃 bind 源)也无法靠持 fd 关闭 TOCTOU 窗口。users/<别人 openId>/下的私有角色对同 bot 的其他用户在 fs 层是可读的——「别人的私有角色不展示、不可切换」仍只由_role-protocol.md的行为约束保证。要做 fs 级隔离得按会话ownerOpenId收窄,但共享 bot 的一个会话可能服务多个发送者,会误伤,需要单独设计。